Skip to content

feat(astro-renderer,core-ts): resolve reference and repeater fields (RFC-0045 step 3a) - #211

Merged
Madraka merged 2 commits into
mainfrom
claude/rfc45-step3a
Nov 12, 2026
Merged

Madraka merged 2 commits into
mainfrom
claude/rfc45-step3a

Conversation

@Madraka

@Madraka Madraka commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

RFC-0045 rollout step 3a: the renderer resolves reference and repeater fields (§4, §8; docs/rfcs/0045-collections-stage-4-references-and-repeaters.md §9 step 3). The purge planner (3b) follows in a second PR. Both types stay refused on every production write path; the tests enable them with SiteGraphStoreOptions.fieldTypes, as 2a and 2b did.

What changes

@cloudhal/astro-renderer

  • references.ts (new):
    • referenceCards: breadth-first over the groups a page's values name, with one groupMembers call per level and a visited set of group ids, so cycles cost nothing.
    • pickMember chooses among the members the view shows.
    • Depth 1 by default, 2 at most. At depth 2, each card's own fields carry their references as cards without fields.
    • Every card spends one of the request's 200 targets (PlaceholderBudget.references; placeholderBudget() includes it, and a theme's own { left } object gets 200 on first use). Past the budget the rest resolve to nothing, with one console.warn per request.
    • ReferenceIndex: members and referrers by group over the snapshot's published entries. It is gate-independent, built lazily once per snapshot and isolate, and filtered per view: reachable for resolution and originals, listed for "used by".
    • referrerRecords and referrersIn are shared by both sources.
  • reference-conversions.ts (new): the §7 conversion maps.
    • The snapshot builds them from its own non-archived entries, kept only while a conversion runs, and caches them per collection.
    • The live source builds them from the working copy.
    • They are asked for only while a collection carries a text ↔ reference marker. The conversion rows themselves are step 4.
  • fields.ts:
    • RenderedField.references (ReferencedEntry cards) and RenderedField.items (repeater items rendered like fields; empty items left out); EntryFieldsOptions.depth.
    • An unresolved target leaves nothing behind: value keeps only the resolved group ids, and there is no title and no empty item.
  • source.ts / live-source.ts: groupMembers, referencedBy and conversionEntries on both sources.
    • The snapshot does no store reads.
    • The live source reads getGroupMembers once per level. For referencedBy it reads the entry_refs rows by (site_id, target_id), expanded by the shared rule (referrerCandidates). It then resolves each candidate with its original from the same pool, exactly as the snapshot index does, so both answer alike by construction. source.ts is 670 lines.
  • EntryFields.astro: a reference renders as a link, with lang on a fallback-locale card and a ul.ch-references when there are several. A repeater renders as an ol.ch-items of nested dls.
  • GET /__cloudhal/features answers { "gates": 2, "fields": 2 }. Nothing passes the capability to a store until step 7.

@cloudhal/core-ts

  • references/reads.ts: getGroupMembers and referrerCandidates. Both filter by site_id and use no correlated outer references.
  • references/conversion-maps.ts: conversionEntries, conversionTargets, conversionCollections and foldText, one builder for every converter. Archived entries count nothing, because a converted value is a newly chosen target.
  • resolveFields(site, entry, original, { entries }) takes an optional fourth argument, and ConvertContext.entries is optional. Both changes are additive.

Tests

  • astro-renderer/test/references.test.ts:
    • cards at depth 1, with nothing of draft or archived targets in the JSON;
    • the locale rule, and default-with-notice;
    • gate instants: T+1h−1, T+1h and T+1h+1, nextGate, and the ungated preview;
    • depth 2: two groupMembers calls, a cycle ending in a field-less card;
    • the budget across two calls, with one warning;
    • an older source without groupMembers;
    • the conversion maps.
  • Live versus snapshot, on a snapshot of the working copy, for the site and for an edition site with an edition chain:
    • every published entry × en/tr/de × depth 1 and 2 gives identical entryFields;
    • every group × five referencedBy queries gives identical results;
    • groupMembers is identical;
    • no field-operation marker is active (asserted);
    • the fixture covers localized values held by translations (a sponsor owned by the Turkish translation), inherited values and a draft holder.
  • references-sweep.test.ts, the route sweep extension (§8): a published referrer names a draft, an archived, an embargoed, an availability.until and a hide-expired target, plus one always shown. The sweep runs at T−1, T+1h−1, T+1h and T+1h+1 over every starter route, with entry routes now drawing fields at depth 2 and their "used by" list (sweep.ts, shared with the gate sweeps).
    • A hidden target's id, path and title appear nowhere.
    • A shown target appears as a card and lists the referrer.
    • A mutation check confirmed it: making membersIn skip the gate filter fails this sweep and two cases in references.test.ts.
  • core-ts/src/testing-reference-reads.ts, in describeSiteGraphStore: getGroupMembers and referrerCandidates on SQLite, Miniflare D1 and DO SQLite (6/6 green).
  • cli/test/old-theme-compat.test.ts: the frozen copy fixtures/theme-v0.8.0 (the v0.8.0 starter, unchanged since).
    • The compat site gains a place reference and a lineup repeater.
    • Themes that draw fields (v0.5.0 and v0.8.0) show the link and the items through the renderer's EntryFields.astro; older themes show neither.
    • v0.8.0 expects the Event preset, since it calls withStructuredData.
  • cli/test/cloudflare-gates.test.ts: the features answer now includes fields: 2.

Watch list

  • Item 2 (purge reads): 3b, not this PR.
  • Item 3 (marker interplay): readers convert through the maps only while a marker runs. The live-versus-snapshot comparison runs with no marker, as the RFC says. During a rename, the live expansion checks the rows' root_key; resolution then decides.

Notes and deviations

  • Group member order. Members of a group come back in source order: one per locale, and pickMember does not depend on order.
  • Budget accounting. Each card counts, so one venue named by 20 listed events costs 20 (matching "a listing of 20 events at depth 1 costs 20").

Locally: lint, size check, typecheck (20/20) and every package's tests pass. Two pnpm check runs overlapped by accident, and in both, Studio's UI tests timed out at 20 to 90 s under the load. This PR changes nothing in apps/studio or packages/ui, and the Studio suite run alone passes: 83 files, 1,339 tests. CI is the clean full run.

Review fixes (bc0fa663)

  1. Reference links get one locale prefix. Cards carry path (the graph path), and EntryFields.astro links with it, so localizeLinks adds the locale once; before, a Turkish page linked /tr/tr/…. url stays the canonical. Tests run the links through linkLocalizer and localizeLinks under both routings, with a fallback card. Making path the localized URL again fails them with /tr/tr/….
  2. A total order for referencedBy. It sorts by date, then id, and the candidate reads use ORDER BY id. A test on a world with equal dates everywhere checks that both sources agree on order and that limit keeps the same entries.
  3. A translation is listed by what its page shows. It resolves with its original while the view shows that original, and without it otherwise, in the shared referrerRecords/referrersIn. The test covers an embargoed original before and after it opens, on both sources:
    • the translation's own copy of a venue the site turned non-localized (the state between set_field_localized true→false phase A and phase B, written directly);
    • a localized sponsor it inherits only once the original is shown.
  4. Live conversion maps are memoized per collection for 2 s, and loaded only from the collections the conversion reads. Not for the source's whole lifetime: cloudhal dev keeps one LiveSource per process, so maps kept for its lifetime would never see edits.
  5. No whole-site scan in the live source. groupMembers and referencedBy pass the rows they read as #split's originals pool.
  6. Referrer records go through the conversion maps in the snapshot index and the live source. The step-4 limitation of the live source is now in watch-list item 3.
  7. One budget per request. The starter passes one placeholderBudget() per request in media.budget; cloudflare-entry doesn't call entryFields. The README tells themes that call entryFields themselves to do the same.
  8. The live shared = 2 path is tested. A localized sponsor on talk is inherited by a translation with none of its own, and not by one with its own value. It is in both the renderer's live-versus-snapshot comparison and the core suite (SQLite, D1, DO SQLite).

The RFC gained an "Amended in 3a" note (ReferencedEntry.path, and referencedBy following each page).

Local checks. No single pnpm check was green, because of load on this machine:

  • Green in full runs: lint, the size ratchet and typecheck (20/20).
  • Tests:
    • the --continue test run passed 17 of 19 packages;
    • core-ts failed there on Miniflare D1 timeouts, but passes on its own (65 files, 1,357 tests);
    • Studio failed there on UI timeouts, but passes on its own (83 files, 1,339 tests);
    • packages/comments failed twice in the D1 tests with fetch failed during a migration, then passed in the --continue run and on its own.
  • This PR's files, run one at a time: the renderer's references (14), references sweep, fields, source, gate sweeps and locales; the core reads suite on all three backends; the old-theme compat builds (4).

🤖 Generated with Claude Code

Madraka and others added 2 commits November 12, 2026 12:42
…RFC-0045 step 3a)

- entryFields: reference cards (ReferencedEntry) by pickMember among the members the
  view shows, depth 1 by default and 2 at most, breadth-first over a visited set of
  groups (one groupMembers call per level), within the request's 200-target budget
  (PlaceholderBudget.references, one console.warn); repeater items rendered like fields;
  unresolved targets leave no id, title or empty item
- GraphSource.groupMembers / referencedBy / conversionEntries on both sources: the
  snapshot answers from gate-independent indexes filtered per view (no store reads);
  the live source reads entry_refs by (site_id, target_id) expanded by the shared rule
  and resolves each candidate like the snapshot index
- core-ts: getGroupMembers and referrerCandidates (shared suite on SQLite, D1 and DO
  SQLite), the conversion-map builder, resolveFields' optional conversion context
- EntryFields.astro: links for references, a nested dl per repeater item
- site Worker features: { gates: 2, fields: 2 }
- tests: live vs snapshot cards and referencedBy (editions, translations' own localized
  values), gate instants, depth, cycles and budget, the route sweep extension, and the
  frozen theme-v0.8.0 copy in old-theme-compat

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Madraka <[email protected]>
- reference cards carry their graph `path`; EntryFields links with it, so the locale
  middleware localizes it once (no /tr/tr/…, no /en/en/… under prefix-all); `url` stays
  the canonical
- referencedBy: a total order (date, then id) in both sources, ORDER BY id in the
  candidate reads; a translation resolves with its original only while the view shows it
  (what its page reads through the gated entryById), without it otherwise
- referrer records resolve through the conversion maps (snapshot index and live source)
- live source: conversion maps memoized per collection (2 s) and loaded only from the
  collections the conversion reads; groupMembers and referencedBy pass the rows they read
  as #split's originals pool (no whole-site scan with expiry rules)
- tests: links through localizeLinks under both routings with a fallback card, tied
  dates and limit, the gated-original case, the live shared = 2 path (both suites)
- README: themes calling entryFields pass one placeholderBudget() per request; RFC:
  "Amended in 3a", and the step-4 live referencedBy note in watch list item 3

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Madraka <[email protected]>
@Madraka
Madraka merged commit 8ee8cd8 into main Nov 12, 2026
8 checks passed
@Madraka
Madraka deleted the claude/rfc45-step3a branch November 12, 2026 12:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant